30. Coding: Depth-Limited Minimax
## Adding a Depth Limit
So far, our minimax function is guaranteed to find an optimal move, but it is impractical for all but the most trivial games. Game trees grow exponentially with each additional level of search, so the algorithm runtime is also exponential. With finite computational resources, we need a way to bound the runtime of the search. Using a fixed depth limit is the simplest mechanism to limit the runtime (although it introduces new problems that we'll see later).
Thad demonstrated the math to estimate a value to use for a fixed depth limit, so now it's time to add a depth limit to our minimax code. We'll add the depth limit as an additional parameter passed to each of the minimax functions, and then we'll update the logic to cut off search when we reach the depth limit.
In the quiz below, you need to add a new parameter named depth
to each of the minimax functions, then update all of the function calls to pass the depth parameter to the next function, and add a new conditional test to cutoff the search when the depth limit is reached. The depth parameter will always be an integer value greater than or equal to 1. Recall that the "depth" of a node in a graph is equal to the number of edges between the node and the root of the tree. (The depth of the root is 0, the depth of the children of the root is 1, etc.)
Note: We aren't modifying the terminal_test()
function to implement the depth limit. We could do it that way, but there are some cases where we'd like to treat a depth cutoff differently than when there are no moves left -- and we wouldn't be able to distinguish those cases because the terminal test only returns a boolean True/False.
Start Quiz:
# TODO: Add a "depth" parameter to each function
# TODO: Update all recursive calls to use the depth parameter
# TODO: Add a new conditional to cut off search when the depth
# limit is reached
# NOTE: The minimax_decision function has been done for you!
def minimax_decision(gameState, depth):
""" Return the move along a branch of the game tree that
has the best possible value. A move is a pair of coordinates
in (column, row) order corresponding to a legal move for
the searching player.
You can ignore the special case of calling this function
from a terminal state.
"""
best_score = float("-inf")
best_move = None
for m in gameState.get_legal_moves():
# call has been updated with a depth limit
v = min_value(gameState.forecast_move(m), depth - 1)
if v > best_score:
best_score = v
best_move = m
return best_move
def min_value(gameState):
""" Return the value for a win (+1) if the game is over,
otherwise return the minimum value over all legal child
nodes.
"""
# TODO: add a depth parameter to the function signature.
if terminal_test(gameState):
return 1 # by Assumption 2
# TODO: add a new conditional test to cut off search
# when the depth parameter reaches 0 -- for now
# just return a value of 0 at the depth limit
v = float("inf")
for m in gameState.get_legal_moves():
# TODO: pass a decremented depth parameter to each
# recursive call
v = min(v, max_value(gameState.forecast_move(m)))
return v
def max_value(gameState):
""" Return the value for a loss (-1) if the game is over,
otherwise return the maximum value over all legal child
nodes.
"""
# TODO: add a depth parameter to the function signature.
if terminal_test(gameState):
return -1 # by assumption 2
# TODO: add a new conditional test to cut off search
# when the depth parameter reaches 0 -- for now
# just return a value of 0 at the depth limit
v = float("-inf")
for m in gameState.get_legal_moves():
# TODO: pass a decremented depth parameter to each
# recursive call
v = max(v, min_value(gameState.forecast_move(m)))
return v
############################################################
# DO NOT MODIFY ANYTHING BELOW THIS LINE #
############################################################
call_counter = 0
def terminal_test(gameState):
""" Return True if the game is over for the active player
and False otherwise.
"""
# NOTE: do NOT modify this function
global call_counter
call_counter += 1
moves_available = bool(gameState.get_legal_moves()) # by Assumption 1
return not moves_available
from copy import deepcopy
xlim, ylim = 3, 2 # board dimensions
class GameState:
"""
Attributes
----------
_board: list(list)
Represent the board with a 2d array _board[x][y]
where open spaces are 0 and closed spaces are 1
_parity: bool
Keep track of active player initiative (which
player has control to move) where 0 indicates that
player one has initiative and 1 indicates player 2
_player_locations: list(tuple)
Keep track of the current location of each player
on the board where position is encoded by the
board indices of their last move, e.g., [(0, 0), (1, 0)]
means player 1 is at (0, 0) and player 2 is at (1, 0)
"""
def __init__(self):
self._board = [[0] * ylim for _ in range(xlim)]
self._board[-1][-1] = 1 # block lower-right corner
self._parity = 0
self._player_locations = [None, None]
def forecast_move(self, move):
""" Return a new board object with the specified move
applied to the current game state.
Parameters
----------
move: tuple
The target position for the active player's next move
"""
if move not in self.get_legal_moves():
raise RuntimeError("Attempted forecast of illegal move")
newBoard = deepcopy(self)
newBoard._board[move[0]][move[1]] = 1
newBoard._player_locations[self._parity] = move
newBoard._parity ^= 1
return newBoard
def get_legal_moves(self):
""" Return a list of all legal moves available to the
active player. Each player should get a list of all
empty spaces on the board on their first move, and
otherwise they should get a list of all open spaces
in a straight line along any row, column or diagonal
from their current position. (Players CANNOT move
through obstacles or blocked squares.) Moves should
be a pair of integers in (column, row) order specifying
the zero-indexed coordinates on the board.
"""
loc = self._player_locations[self._parity]
if not loc:
return self._get_blank_spaces()
moves = []
rays = [(1, 0), (1, -1), (0, -1), (-1, -1),
(-1, 0), (-1, 1), (0, 1), (1, 1)]
for dx, dy in rays:
_x, _y = loc
while 0 <= _x + dx < xlim and 0 <= _y + dy < ylim:
_x, _y = _x + dx, _y + dy
if self._board[_x][_y]:
break
moves.append((_x, _y))
return moves
def _get_blank_spaces(self):
""" Return a list of blank spaces on the board."""
return [(x, y) for y in range(ylim) for x in range(xlim)
if self._board[x][y] == 0]
import minimax
import gamestate as game
# Test the depth limit by checking the number of nodes visited
# -- recall that minimax visits every node in the search tree,
# so if we search depth one on an empty board then minimax should
# visit each of the five open spaces
depth_limit = 1
expected_node_count = 5
rootNode = game.GameState()
_ = minimax.minimax_decision(rootNode, depth_limit)
print("Expected node count: {}".format(expected_node_count))
print("Your node count: {}".format(minimax.call_counter))
if minimax.call_counter == expected_node_count:
print("That's right! Looks like your depth limit is working!")
else:
print("Uh oh...looks like there may be a problem.")
def minimax_decision(gameState, depth):
""" Return the move along a branch of the game tree that
has the best possible value. A move is a pair of coordinates
in (column, row) order corresponding to a legal move for
the searching player.
You can ignore the special case of calling this function
from a terminal state.
"""
best_score = float("-inf")
best_move = None
for m in gameState.get_legal_moves():
# call has been updated with a depth limit
v = min_value(gameState.forecast_move(m), depth - 1)
if v > best_score:
best_score = v
best_move = m
return best_move
def min_value(gameState, depth):
""" Return the value for a win (+1) if the game is over,
otherwise return the minimum value over all legal child
nodes.
"""
if terminal_test(gameState):
return 1 # by Assumption 2
# New conditional depth limit cutoff
if depth <= 0: # "==" could be used, but "<=" is safer
return 0
v = float("inf")
for m in gameState.get_legal_moves():
# the depth should be decremented by 1 on each call
v = min(v, max_value(gameState.forecast_move(m), depth - 1))
return v
def max_value(gameState, depth):
""" Return the value for a loss (-1) if the game is over,
otherwise return the maximum value over all legal child
nodes.
"""
if terminal_test(gameState):
return -1 # by assumption 2
# New conditional depth limit cutoff
if depth <= 0: # "==" could be used, but "<=" is safer
return 0
v = float("-inf")
for m in gameState.get_legal_moves():
# the depth should be decremented by 1 on each call
v = max(v, min_value(gameState.forecast_move(m), depth - 1))
return v
############################################################
# DO NOT MODIFY ANYTHING BELOW THIS LINE #
############################################################
call_counter = 0
def terminal_test(gameState):
""" Return True if the game is over for the active player
and False otherwise.
"""
# NOTE: do NOT modify this function
global call_counter
call_counter += 1
moves_available = bool(gameState.get_legal_moves()) # by Assumption 1
return not moves_available